Published on

A Tale of Breaking Android Decompilers and Unpackers.

Authors
  • avatar
    Name
    Ajin Deepak
    Twitter

Even tho the title says it's a tale, it's pretty relevant even today. I got this idea for this blog when I was analyzing something in JADX. If you don't know about JADX, it's basically a decompiler which can be used to decompile Java applications, especially Android APKs.

The good thing is that this has been fixed in newer versions of JADX, but if you're using an older version, you will still encounter the issue but the bad thing is that some unpackers, like APKTool, still don’t handle it properly.

So what's this blog about? Let's start with the WHYs.

I was using JADX 1.4.6 to analyze a sample. When I tried to take a look at the manifest, the below happened.

I had no idea why this had happened. I dug deeper and eventually found some answers.

Manifest 101

So let's look at the structure of the manifest. As I don't know shit about this and couldn't find much information either, I just used the AXML template for 010 Editor.

https://www.sweetscape.com/010editor/repository/files/AndroidManifest.bt

Let’s take a look at a sample application. I’ll upload the APK to github, the link will be available at the end of the blog.

This is very basic application. As we can see there's nothing much in this APK , We can see that in the manifest.

Now let's pop out the manifest from the APK.

ad2001@AD2001:~$ unzip -o app-debug.apk AndroidManifest.xml
Archive:  base.apk
  inflating: AndroidManifest.xml
ad2001@AD2001:~$

Let's analyze this manifest in 010 editor.

Change the display to hex.

Let's take a look at the first four bytes.

This first four bytes is like the signature of the manifest, it's similar to the MZ header we see in PE files.

This structure is defined by the Binary XML format used in AXML. While it’s not documented by Google, researchers and tools like AXMLPrinter2, apktool, and 010 templates have reverse-engineered it.

So basically first four bytes are:

OffsetBytesPurpose
0x000x03 00Chunk type (RES_XML_TYPE, value 0x0003)
0x020x08 00Header size (always 0x08 bytes)

We can see it in the template from the 010 editor.

To use the template you can open the template and run it.

Now let's try something , we'll change the value of the first offset, which is 0x03, to 0x00 and see what happens.

And save it.

Now let's push this manifest back to the APK.

zip -u app-debug.apk AndroidManifest.xml

Now let's zip align it.

ad2001@AD2001:~$ zipalign -p 4 app-debug.apk aligned.apk

Finally we need to sign this. You can create a keystore and use it.

keytool -genkeypair -v \
  -keystore my-release-key.jks \
  -alias myalias \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000

Add your password and other details.

Let's sign it.

apksigner sign \
  --ks <your_keystore>.jks \
  --ks-key-alias <alias> \
  --ks-pass pass:<password> \
  --key-pass pass:<password> \
  --min-sdk-version 21 \
  --out signed.apk aligned.apk

We got our signed APK. Let's trying installing this and see if this works in our device.

Yes it was installed, Now let's take a look on it in JADX.

)

Well nothing happened. Let's change again.

Do the same process we do again and sign the APK.

Let's take a look again in JADX.

Interesting, the formatting is very off and the strings are not properly parsed. Let's try to install this APK and see if this works.

Task failed successfully. Now what ?

So I have the original manifest and the modified manifest that breaks the decompiler. Let's try to diff the original manifest with the one from the sample. You can use the compare option.

First thing we can see is the column 4, in the malicious manifest it's 0x9C and in the original one it's 0x98 , so basically a 4 byte difference. Note this down.

If you run the template again and point your mouse there, we can see the field.

It's the chunkSize field.

Let's see the chunk Header.

// Define Chunk Header
typedef struct {
    ushort type;
    ushort headerSize <format=hex>;
    uint chunkSize <format=hex>;
} ResChunkHeader;

struct ResChunkHeader resHeader:

This is a sub-structure inside StringChunk. It represents the standard header used at the beginning of all chunks in Android's binary XML (AXML) format.

  • type
  • headerSize
  • chunkSize
FieldSizeDescription
type2 bytesIdentifies the type of the chunk.
headerSize2 bytesSize of just the header (this struct), usually 8 or 16 bytes.
chunkSize4 bytesTotal size of the chunk including the header and data.

struct StringChunk stringChunk:

This is a parsed structure named StringChunk, which represents the string pool chunk in the AXML format. We will discuss about this string pool chunk later.

Now let's see where's the next difference.

We can see the difference in the column 'C' and 'D'. If you point your mouse there,

Similar to above we can see the chunkSize is 0xFF8 for the original manifest

This header is called stringChunk, which corresponds to the String Pool Chunk in AXML naming standards. Here we can see that the total size of the string pool chunk is 0x0FFC which is defined in the chunkSize, including the header. Next question here is what's this string pool chunk used for ?

It acts like as a global table of strings and is used throughout the XML file. Instead of repeating strings like tag names, attribute names, or same values multiple times, the binary XML uses this indexes into this chunk to refer to them. If you scroll below you can see this.

Let's see this structure.

typedef struct {
    ResChunkHeader resHeader;       // Standard chunk header (type, headerSize, chunkSize)

    uint scStringCount;             // Number of strings in the pool
    uint scStyleCount;              // Number of style entries (often 0)
    uint flags;                     // Encoding: 0x100 = UTF-8, otherwise UTF-16

    uint scStringPoolOffset;        // Offset (from start of chunk) to actual string data
    uint scStylePoolOffset;         // Offset to style data (optional)

    uint scStringOffsets[scStringCount]; // Offset table for each string (relative to string pool)

    uint scStyleOffsets[scStyleCount];   // (Optional) Offsets to style data

    // Strings follow here at: 0x8 + scStringPoolOffset
    // You loop through each offset and read a string from the pool
} StringChunk;

Now if you look at the original manifest, this value is 0x0ffc which is just four bytes more. Note this down somewhere.

Next difference is just below this value.

We can see that in the original manifest, the value of scStringPoolOffset is 0x18, but in the malicious one, it is 0x1C, which is also a 4-byte difference. As we seen above scStringPoolOffset is the offset (in bytes) from the start of the string pool to the actual string content block. Also note this down.

Let's scroll down.

We can see that four null bytes are added in the offset 0x120 of our malicious manifest. If we scroll down we can see that the malicious manifest has four more extra bytes.

Okay now the question is why this breaks the manifest?

When we add 4 null bytes into the string pool of the AndroidManifest.xml and adjust the file size, string chunk size and scStringPoolOffset fields, we do not update the entries in the scStringOffsets[] array. This array contains offsets that are relative to scStringPoolOffset, so when we shift the base by 4 bytes without adjusting the actual string offsets, the parser ends up reading invalid positions, leading to corrupted or malformed strings. Since the offsets are calculated based on the base address, moving the base while keeping the offsets unchanged causes everything to misalign. As a result, tools like JADX and apktool try to decode garbage data and fail. Android itself doesn’t really care, though as long as the strings it needs at runtime are valid, it happily installs and runs the APK.

So inorder to fix this we just an have to substract these 4 bytes and remove the added null bytes.

I have created a script which will do this . (Yes i used chatgpt too).

import argparse
import os
import zipfile
import shutil
import struct
import subprocess

def extract_apk(apk_path, extract_dir):
    with zipfile.ZipFile(apk_path, 'r') as zip_ref:
        zip_ref.extractall(extract_dir)

def insert_null_bytes(manifest_bytes, null_bytes):
    FILE_SIZE_OFFSET = 0x04
    STRING_CHUNK_SIZE_OFFSET = 0x0C
    STRING_POOL_OFFSET_OFFSET = 0x1C

    original_file_size = struct.unpack('<I', manifest_bytes[FILE_SIZE_OFFSET:FILE_SIZE_OFFSET+4])[0]
    original_chunk_size = struct.unpack('<I', manifest_bytes[STRING_CHUNK_SIZE_OFFSET:STRING_CHUNK_SIZE_OFFSET+4])[0]
    original_pool_offset = struct.unpack('<I', manifest_bytes[STRING_POOL_OFFSET_OFFSET:STRING_POOL_OFFSET_OFFSET+4])[0]

    insert_pos = original_pool_offset + 8
    patched = (
        manifest_bytes[:insert_pos] +
        (b'\x00' * null_bytes) +
        manifest_bytes[insert_pos:]
    )

    patched = bytearray(patched)
    struct.pack_into('<I', patched, FILE_SIZE_OFFSET, original_file_size + null_bytes)
    struct.pack_into('<I', patched, STRING_CHUNK_SIZE_OFFSET, original_chunk_size + null_bytes)
    struct.pack_into('<I', patched, STRING_POOL_OFFSET_OFFSET, original_pool_offset + null_bytes)

    return bytes(patched)

def rebuild_apk(folder_path, out_apk):
    shutil.make_archive("temp_unsigned", 'zip', folder_path)
    os.rename("temp_unsigned.zip", out_apk)

def zipalign(input_apk, output_apk):
    if os.path.exists(output_apk):
        os.remove(output_apk)
    subprocess.run(["zipalign", "-f", "-p", "4", input_apk, output_apk], check=True)


def sign_apk(input_apk, output_apk, keystore, alias, storepass, keypass):
    subprocess.run([
        "apksigner", "sign",
        "--ks", keystore,
        "--ks-key-alias", alias,
        "--ks-pass", f"pass:{storepass}",
        "--key-pass", f"pass:{keypass}",
        "--min-sdk-version", "21",
        "--out", output_apk,
        input_apk
    ], check=True)

def main():
    parser = argparse.ArgumentParser(description="Glitch manifest and sign APK")
    parser.add_argument("--apk", required=True, help="Input APK file")
    parser.add_argument("--null-bytes", type=int, default=4, help="Null bytes to inject")
    parser.add_argument("--keystore", required=True, help="Keystore path")
    parser.add_argument("--alias", required=True, help="Key alias")
    parser.add_argument("--storepass", required=True, help="Keystore password")
    parser.add_argument("--keypass", required=True, help="Key password")
    parser.add_argument("--output", default="signed.apk", help="Output signed APK")

    args = parser.parse_args()
    
    if args.null_bytes % 4 != 0:
    	print("[-] Error: --null-bytes must be a multiple of 4.")
    	exit(1)

    # Step 0: Make a safe copy of the original APK
    working_apk = "working.apk"
    shutil.copyfile(args.apk, working_apk)
    print(f"[+] Copied {args.apk} -> {working_apk}")

    # Step 1: Extract AndroidManifest.xml from working copy
    subprocess.run(["unzip", "-o", working_apk, "AndroidManifest.xml"], check=True)

    # Step 2: Patch the manifest
    with open("AndroidManifest.xml", "rb") as f:
        manifest_bytes = f.read()

    patched = insert_null_bytes(manifest_bytes, args.null_bytes)

    with open("AndroidManifest.xml", "wb") as f:
        f.write(patched)
    print(f"[+] Patched AndroidManifest.xml with {args.null_bytes} null bytes")

    # Step 3: Inject it back into the working APK
    subprocess.run(["zip", "-u", working_apk, "AndroidManifest.xml"], check=True)

    # Step 4: Zipalign
    aligned_apk = "aligned.apk"
    if os.path.exists(aligned_apk):
        os.remove(aligned_apk)
    subprocess.run(["zipalign", "-f", "-p", "4", working_apk, aligned_apk], check=True)

    # Step 5: Sign it
    if os.path.exists(args.output):
        os.remove(args.output)

    sign_apk(aligned_apk, args.output, args.keystore, args.alias, args.storepass, args.keypass)

    print(f"\n✅ Final APK created: {args.output}")


if __name__ == "__main__":
    main()

This will pretty much do what we saw above.

Finds key offsets:

  • file size (offset 0x04)
  • string chunk size (offset 0x0C)
  • string pool offset (offset 0x1C)

And Injects null bytes at: string pool offset + 8

Updates all size fields accordingly.

Let's use our sample APK and test it out.

Let's first open this in JADX and see if it broke it or not.

Wow nice, it worked. Let's also use APKTool, i will be using the latest one.

As you can see it also crashed APKTool.

Fix and next steps

So if you encounter this sorta APKS what will you do ?

  • Get the error message from JADX, fix it one by one in 010 Editor.
  • Use JEB or latest version of JADX, in the case of APKtool you have to manually fix the manifest.

Next steps are to tamper with other headers in manifest and see what will break. Also other good area to play around with is the android resource file.

Yeah that's pretty much it.

The files are attached in the repo:

https://github.com/DERE-ad2001/manifest-kill